Содержание¶

Для навигации по содержанию необходимо кликнуть на интересующий раздел. Чтобы вернуться в содержание после клика на раздел, можно кликнуть снова на название раздела.

  • Задание
    • Введение
      • Требования к модели:
      • Подсказки:
      • Описание бизнес-процесса:
  • Источники
  • Загрузка данных
    • Временной ряд из задания
    • Даты уплаты налогов и их типы
    • Выходные и рабочие дни
    • RUONIA
    • Инфляция
    • Курс доллара
    • Индекс МосБиржи (MOEX)
  • Вспомогательные функции для визуализации
    • Предобработка данных
  • Анализ данных
    • Первичные найденные особенности
    • Рассмотрение данных после 2020 года
    • Автокорреляции на данных с 2020 года
    • Влияние внешних факторов на баланс
      • RUONIA
      • MOEX
      • USD
      • Инфляция
  • Модель для предсказания
    • Описание пайплайна модели
    • Промежуток обучения и дообучения
    • Выбор метрики
      • Бизнес-постановка
      • Использование метрики в оптимизации
    • Выбор признаков (feature selection)
      • diversity_index (Индекс разнообразия)
      • pairwise_jaccard (Попарный Жаккар)
      • feature_consistency (Консистентность)
    • Модель без внешних признаков
  • Определение разладок

Проект выполнили: Ткалич Леонид, Шамшединов Илья, Шумилин Андрей, Шурыгин Всеволод.

Задание¶

Введение- В рамках проекта №2 требуется реализовать автоматизированный пайплайн для прогнозирования значений временного ряда на следующий день.¶

  • Ряды представляют из себя срезы в обозначенные моменты времени притоков, оттоков и сальдо показателя, связанного с потоками ликвидности Банка
  • Прогноз требуется строить для значения сальдо (разнинцы между притоками и оттоками)
  • Заказчик модели высказал пожелание, чтобы ошибка прогноза составляла не более 0.42 в абсолютном значении

Требования к модели:¶

  • Выбор оптимизируемой метрики должен быть основан на потребностях бизнеса
  • Модель может использовать внешние факторы (см подсказки)
  • Модель должна иметь модуль отбора признаков. Метод отбора должен быть болеее стабильным относительно альтернатив. Сравнение должно быть проведено минимум с одним методом из каждой категории: встроенные, оберточные и фильтрационные. При этом как минимум одна из альтернатив должна исследовать нелинейную зависимость.
  • Модель должна автоматически подбирать гиперпараметры, оптимизируя целевую метрику
  • Для модели должна быть подобрана частота калибровки, если модель калибруется долго, и проверена ее достаточность
  • Блоки должны быть подписаны и кратко описаны (чем руководствовались при реализации, как работает)
  • Модель должна автоматически дообучаться. Все модули должны работать без ручных корректировок. Выбор периода для дообучения должен быть обоснован.
  • В модели должен быть модуль выявления разладки для подачи сигнала о возможной необходимости переключения на на ручное управление процессом/внеплановое дообучение

Подсказки:- Можно использовать факторы, сконструированные из таргета (лаги, средние и т.п.)¶

  • Могут помочь макроэкономические факторы
  • Могут помочь даты налоговых дней

Описание бизнес-процесса:¶

  • Прогнозная величина позволяет установить сальдо поступлений и списаний за день.
  • На основании прогноза позиционер (управляет ликвидностью) принимает решение о выделении средств на размещение на рынке деривативов для получения дополнительной маржи (доходность считать примерно ключ+0.5%)
  • В случае, если на конец дня образуется профицит ликвидности, его можно разместить в ЦБ по overnight ставке, равной ключу-0.9%
  • В случае, если на конец дня образуется дефицит ликвидности, его можно покрыть за счет займа по overnight ставке, равной ключ+1%

Источники¶

  • Временной ряд от заказчика
  • КонсультантПлюс
    • Даты выплаты налогов в разрезе по типам
    • Выходные и нерабочие дни
  • Сайт Банка России
    • Инфляция
    • Ставка (RUONIA)
    • Инфляция
    • Курс доллара
  • Сайт Московской Биржи - индекс МосБиржи
In [1]:
ts_filepath = 'data/project_2.csv'
holidays_filepath = 'data/holidays.pickle'
taxes_filepath = 'data/taxes.csv'
rate_filepath = 'data/rounia.xlsx'
inflation_filepath = 'data/inflation_and_cb_rate.xlsx'
currency_path = 'data/usd.csv'
moex_path = 'data/IMOEX.csv'
In [2]:
%load_ext autoreload
%autoreload 2

import warnings
import requests
import os
import json

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import pickle
import plotly.graph_objects as go
from matplotlib.ticker import AutoMinorLocator
from statsmodels.tsa.seasonal import seasonal_decompose
from statsmodels.tsa.stattools import adfuller, kpss
from statsmodels.tsa.stattools import acf, pacf
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from bs4 import BeautifulSoup

pd.set_option('display.max_columns', None)
pd.set_option('display.max_rows', 150)

from src.models import BaselineModel # Наша модель
from src.model_evaluation.metrics import TargetLoss, MaxAE, MAE, NumCriticalErrors, MoneyLoss

Загрузка данных¶

Временной ряд из задания¶

In [3]:
data = pd.read_csv(ts_filepath)
data.columns = [col.lower() for col in data]
data['date'] = pd.to_datetime(data['date'])
for col in data.columns[1:]:
    data[col] = data[col].astype(str).str.replace(',', '.').astype(float)
data = data.set_index('date')
print(data.shape)
display(data.describe().round(3))
data.head()
(1543, 3)
income outcome balance
count 1543.000 1543.000 1543.000
mean 1.085 1.134 -0.049
std 0.839 0.902 0.292
min 0.000 0.000 -2.510
25% 0.000 0.000 -0.143
50% 1.330 1.330 0.000
75% 1.670 1.740 0.038
max 5.110 5.000 1.410
Out[3]:
income outcome balance
date
2017-01-09 1.340000 1.490000 -0.155904
2017-01-10 1.068610 1.194182 -0.125572
2017-01-11 0.944429 0.936663 0.007767
2017-01-12 1.670000 0.875379 0.800391
2017-01-13 0.955924 0.975645 -0.019721
In [4]:
data.isnull().sum()
Out[4]:
income     0
outcome    0
balance    0
dtype: int64
In [5]:
all_dates = pd.DataFrame({
    'date': pd.date_range(data.index.min(), data.index.max(), freq='D')
})
all_data = pd.merge(
    left=all_dates,
    right=data.reset_index(),
    on=['date'],
    how='left'
).set_index('date')
all_data.isnull().sum()
Out[5]:
income     0
outcome    0
balance    0
dtype: int64
In [6]:
data.index.min(), data.index.max()
Out[6]:
(Timestamp('2017-01-09 00:00:00'), Timestamp('2021-03-31 00:00:00'))

Отлично, в данных нет пропусков и присутствует информация за каждый день с 09.01.2017 по 31.03.2021

Посмотрим как выглядит ряд далее после загрузки остальных данных

Даты уплаты налогов и их типы¶

In [7]:
def _parse_links_with_calendars_from_tax_page(url: str):
    r = requests.get(url)
    soup = BeautifulSoup(r.text, 'html.parser')
    div = soup.find('div', class_='document-page__toc').find('ul')
    link_to_calendar = 'https://www.consultant.ru' + div.find(lambda tag: 'Часть 3. Тематический Календарь' in tag.text and tag.name == 'a').attrs['href']
    calendar_page = BeautifulSoup(requests.get(link_to_calendar).text, 'html.parser')
    tax_types_info = [c for c in calendar_page.find('div', 'document-page__toc').contents if c.text.strip(' \n')][0]
    links = ['https://www.consultant.ru/' + item.attrs['href'] for item in tax_types_info.findAll('a')]
    return links

def _parse_tax_dates_from_tax_calendar_page(url: str):
    r = requests.get(url)
    soup = BeautifulSoup(r.text)
    columns = soup.find('div', class_='doc-table').findAll('td')
    tax_type = columns[0].text.replace('\n', '')
    tax_result = {
        tax_type: {}
    }
    tax_subtype = ''
    prev_paragraph_has_dates = True
    for paragraph in columns[1].findAll('p'):
        if paragraph.text:
            all_links = paragraph.findAll('a')
            has_dates = len(all_links) > 1
            if has_dates:
                dates_to_add = [
                     item.text for item in all_links 
                     if all([letter.isdigit() for letter in item.text.replace('.', '')])
                ]
                dates_to_add = [
                    item for item in dates_to_add if item
                ]
                if dates_to_add:
                    if not tax_subtype:
                        tax_subtype = 'no_type'
                    if tax_subtype in tax_result:
                        tax_result[tax_type][tax_subtype] += dates_to_add
                    else:
                        tax_result[tax_type][tax_subtype] = dates_to_add
            else:
                if prev_paragraph_has_dates:
                    tax_subtype = ''
                tax_subtype += paragraph.text + ' '
                prev_paragraph_has_dates = False
    return tax_result
In [8]:
if os.path.exists(taxes_filepath):
    taxes = pd.read_csv(taxes_filepath)
else:
    dicts = []
    urls = {
        2017: 'https://www.consultant.ru/document/cons_doc_LAW_208577/',
        2018: 'https://www.consultant.ru/document/cons_doc_LAW_284538/',
        2019: 'https://www.consultant.ru/document/cons_doc_LAW_312984/',
        2020: 'https://www.consultant.ru/document/cons_doc_LAW_339977/',
        2021: 'https://www.consultant.ru/document/cons_doc_LAW_371805/'
    }
    taxes = []
    for year, url in urls.items():
        links = _parse_links_with_calendars_from_tax_page(url)
        cur_taxes = {}
        for link in links:
            cur_taxes |= _parse_tax_dates_from_tax_calendar_page(link)
        year_taxes = []
        for tax_type, values in cur_taxes.items():
            for tax_subtype, tax_dates in values.items():
                cur_df = pd.DataFrame(tax_dates)
                cur_df.columns = ['date']
                # Ошибка в К+ на 2020 годе
                cur_df['date'] = cur_df['date'].str.replace('20.20', '20.10')
                cur_df['date'] = pd.to_datetime(
                    cur_df['date'] + f'.{year}', format='%d.%m.%Y'
                )
                cur_df['tax_type'] = tax_type
                cur_df['tax_subtype'] = tax_subtype
                year_taxes.append(cur_df)
        year_taxes = pd.concat(year_taxes)
        taxes.append(year_taxes)
    taxes = pd.concat(taxes)
    taxes.to_csv(filepath, index=False)
taxes['date'] = pd.to_datetime(taxes['date'])
taxes.head()
Out[8]:
date tax_type tax_subtype
0 2017-01-20 Сведения о среднесписочной численности работников no_type
1 2017-02-20 Сведения о среднесписочной численности работников no_type
2 2017-03-20 Сведения о среднесписочной численности работников no_type
3 2017-04-20 Сведения о среднесписочной численности работников no_type
4 2017-05-22 Сведения о среднесписочной численности работников no_type

Выходные и рабочие дни¶

In [9]:
if os.path.exists(holidays_filepath):
    with open(holidays_filepath, 'rb') as f:
        holidays = pickle.load(f)
else:
    years = np.unique(data.index.year)
    holidays = {
        'holidays': [],
        'preholidays': [],
        'nowork': []
    }
    for year in years:
        url = f'https://raw.githubusercontent.com/d10xa/holidays-calendar/master/json/consultant{year}.json'
        r = requests.get(url)
        cal = json.loads(r.text)
        for key, val in cal.items():
            holidays[key] += val
    with open(holidays_filepath, 'wb') as f:
        pickle.dump(holidays, f)
for key in holidays:
    holidays[key] = pd.to_datetime(holidays[key])
print(holidays.keys())
holidays
dict_keys(['holidays', 'preholidays', 'nowork'])
Out[9]:
{'holidays': DatetimeIndex(['2017-01-01', '2017-01-02', '2017-01-03', '2017-01-04',
                '2017-01-05', '2017-01-06', '2017-01-07', '2017-01-08',
                '2017-01-14', '2017-01-15',
                ...
                '2021-11-28', '2021-12-04', '2021-12-05', '2021-12-11',
                '2021-12-12', '2021-12-18', '2021-12-19', '2021-12-25',
                '2021-12-26', '2021-12-31'],
               dtype='datetime64[ns]', length=591, freq=None),
 'preholidays': DatetimeIndex(['2017-02-22', '2017-03-07', '2017-11-03', '2018-02-22',
                '2018-03-07', '2018-04-28', '2018-05-08', '2018-06-09',
                '2018-12-29', '2019-02-22', '2019-03-07', '2019-04-30',
                '2019-05-08', '2019-06-11', '2019-12-31', '2020-06-11',
                '2020-11-03', '2020-12-31', '2021-02-20', '2021-04-30',
                '2021-06-11', '2021-11-03'],
               dtype='datetime64[ns]', freq=None),
 'nowork': DatetimeIndex(['2020-03-30', '2020-03-31', '2020-04-01', '2020-04-02',
                '2020-04-03', '2020-04-06', '2020-04-07', '2020-04-08',
                '2020-04-09', '2020-04-10', '2020-04-13', '2020-04-14',
                '2020-04-15', '2020-04-16', '2020-04-17', '2020-04-20',
                '2020-04-21', '2020-04-22', '2020-04-23', '2020-04-24',
                '2020-04-27', '2020-04-28', '2020-04-29', '2020-04-30',
                '2020-05-06', '2020-05-07', '2020-05-08', '2020-06-24',
                '2021-05-04', '2021-05-05', '2021-05-06', '2021-05-07',
                '2021-11-01', '2021-11-02', '2021-11-03'],
               dtype='datetime64[ns]', freq=None)}

RUONIA¶

In [10]:
with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    rate_df = pd.read_excel(rate_filepath, engine="openpyxl")
rate_df['date'] = pd.to_datetime(rate_df['DT'])
rate_df = rate_df[['date', 'ruo']].rename(columns={'ruo': 'rate'})
rate_df = rate_df.sort_values('date').set_index('date')
rate_df.head()
Out[10]:
rate
date
2017-01-09 10.13
2017-01-10 9.93
2017-01-11 9.97
2017-01-12 9.93
2017-01-13 10.06

Инфляция¶

In [11]:
with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    inflation_df = pd.read_excel(inflation_filepath, engine="openpyxl")

inflation_df['Дата'] = inflation_df['Дата'].astype(str)

def fix_year(date_str):
    parts = date_str.split('.')
    if len(parts) == 2:
        month, year = parts
        if len(year) == 3:
            year += '0'
        return f"{month}.{year}"
    return date_str

inflation_df['Дата'] = inflation_df['Дата'].apply(fix_year)
inflation_df['date'] = pd.to_datetime(inflation_df['Дата'], format='%m.%Y')
inflation_df = inflation_df[['date', 'Инфляция, % г/г']].rename(columns={'Инфляция, % г/г': 'inflation'})
inflation_df = inflation_df.sort_values('date').set_index('date')
inflation_df.head()
Out[11]:
inflation
date
2017-01-01 5.0
2017-02-01 4.6
2017-03-01 4.3
2017-04-01 4.1
2017-05-01 4.1

Курс доллара¶

In [12]:
currency_df = pd.read_csv(currency_path)
currency_df['date'] = pd.to_datetime(currency_df['date'])
currency_df = currency_df.rename(columns={'value': 'usd/rub'}).set_index('date')
currency_df = currency_df.sort_index()
currency_df.head()
Out[12]:
usd/rub
date
2017-01-10 59.8961
2017-01-11 59.9533
2017-01-12 60.1614
2017-01-13 59.4978
2017-01-14 59.3700

Индекс МосБиржи (MOEX)¶

In [13]:
moex_df = pd.read_csv(moex_path, encoding='windows-1251', sep=';')
moex_df['date'] = pd.to_datetime(moex_df['TRADEDATE'], format='%d.%m.%Y')
moex_df = pd.DataFrame(moex_df.set_index('date')['CLOSE'].str.replace(',', '.').astype(float)).rename(columns={'CLOSE': 'MOEX'})
moex_df = moex_df.sort_index()
moex_df.head()
Out[13]:
MOEX
date
2017-01-03 2285.43
2017-01-04 2263.90
2017-01-05 2220.35
2017-01-06 2213.93
2017-01-09 2211.25

Вспомогательные функции для визуализации¶

In [14]:
def make_ax_better(ax, locators=(), n_locators=None):
    ax.spines['right'].set_visible(False)
    ax.spines['top'].set_visible(False)
    if 'x' in locators:
        ax.xaxis.set_minor_locator(AutoMinorLocator(n_locators))
    if 'y' in locators:
        ax.yaxis.set_minor_locator(AutoMinorLocator(n_locators))
    if locators:
        ax.tick_params(which='minor', length=2.5)
        ax.tick_params(which='major', length=5)
        ax.grid(which='minor', linewidth=0.15, color='tab:grey', alpha=0.25)
    ax.grid(linewidth=0.5, color='tab:grey', alpha=0.25)
    
def to_bold(s):
    s = ' '.join([r"$\bf{" + str(item) + "}$" for item in s.split(' ')])
    return s.replace('_', '}$_$\\bf{')

def plot_ts_plotly(
    series_list: list[pd.Series],
    colors: list[str] = ['#42CAFD'],
    title: str = '',
    xaxis_title: str = '',
    yaxis_title: str = '',
    fig_size: tuple[int, int] = (1000, 400),  # Размер фигуры (ширина, высота)
    v_lines: list[dict] = []
):
    # Создаем фигуру
    fig = go.Figure()
    for series, color in zip(series_list, colors):
        fig.add_trace(
            go.Scatter(
                x=series.index,
                y=series,
                mode='lines',
                name=series.name,
                line=dict(color=color),
                meta=[series.name],
                hovertemplate='<b>%{meta[0]}: %{y:.3f}</b><extra></extra>'  # Добавляем название тикера
            )
        )

    # Настраиваем заголовок и оси
    fig.update_layout(
        title={
            'text': title,
            'y': 0.95,  # Позиция заголовка по вертикали
            'x': 0.5,    # Позиция заголовка по горизонтали (центр)
            'xanchor': 'center',  # Центрируем заголовок
            'yanchor': 'top',     # Привязка к верхней части
            'font': dict(size=22)  # Размер шрифта заголовка
        },
        xaxis_title=xaxis_title,
        yaxis_title=yaxis_title,
        template='plotly_white',
        legend=dict(
            x=0.5,  # Центрируем легенду по горизонтали
            y=-0.2,  # Размещаем легенду ниже графика
            xanchor='center',  # Привязка к центру
            yanchor='top',     # Привязка к верхней части
            orientation='h',  # Горизонтальная ориентация
            font=dict(size=12),  # Размер шрифта легенды
            traceorder='normal',  # Порядок элементов легенды
            itemwidth=50,  # Ширина элемента легенды
            itemsizing='constant',  # Фиксированный размер элементов
            bordercolor='lightgray',  # Цвет границы легенды
            borderwidth=1,  # Ширина границы легенды
            bgcolor='rgba(255, 255, 255, 0.8)',  # Цвет фона легенды
            # columns=legend_cols  # Количество столбцов в легенде
        ),
        hovermode='x unified',
        width=fig_size[0],  # Ширина фигуры
        height=fig_size[1],  # Высота фигуры
        margin=dict(l=50, r=50, b=50, t=100)  # Отступы (left, right, bottom, top)
    )

    # Настраиваем оси
    fig.update_xaxes(
        showgrid=True,
        gridwidth=1,
        gridcolor='lightgray',
        minor=dict(
            ticklen=4,  # Длина minor-тиков
            tickcolor='gray',  # Цвет minor-тиков
            showgrid=True,  # Показываем minor-сетку
            gridcolor='rgba(211, 211, 211, 0.5)',  # Цвет minor-сетки (светло-серый с прозрачностью)
            griddash='dot'  # Стиль minor-сетки (точечный)
        )
    )
    fig.update_yaxes(
        showgrid=True,
        gridwidth=1,
        gridcolor='lightgray',
        minor=dict(
            ticklen=4,  # Длина minor-тиков
            tickcolor='gray',  # Цвет minor-тиков
            showgrid=True,  # Показываем minor-сетку
            gridcolor='rgba(211, 211, 211, 0.5)',  # Цвет minor-сетки (светло-серый с прозрачностью)
            griddash='dot'  # Стиль minor-сетки (точечный)
        )
    )
    for v_line_kws in v_lines:
        fig.add_vline(
            x=str(v_line_kws['x']), 
            line_width=2,
            line_dash="dash", 
            line_color=v_line_kws.get('color', '#292F36'), 
        )

    # Показываем график
    fig.show()

def plot_decomposed_ts(
    ts: pd.Series,
    model: str = 'additive',
    color: str = 'tab:blue'
):
    decomposed = seasonal_decompose(ts, model=model, extrapolate_trend='freq')
    decomposed = pd.concat([decomposed.observed, decomposed.seasonal, decomposed.trend, decomposed.resid], axis=1)
    fig, axes = plt.subplots(figsize=(15, 16), nrows=4, dpi=150)
    for col, ax in zip(decomposed, axes):
        sns.lineplot(decomposed[col], ax=ax)
        make_ax_better(ax, locators=['x', 'y'])
        ax.set_title(col, fontsize=25)
        ax.set_xlabel('')
        ax.set_xlim(decomposed.index.min() - pd.DateOffset(days=7), decomposed.index.max() + pd.DateOffset(days=7))
    plt.tight_layout(h_pad=0.5)

Предобработка данных¶

Рассчитаем датафрейм, в который добавим сразу несколько рядов

In [15]:
data_df = data.copy()
inflation_df['year'] = inflation_df.index.year
inflation_df['month'] = inflation_df.index.month
data_df['year'] = data_df.index.year
data_df['month'] = data_df.index.month
data_with_inflation = data_df.merge(inflation_df, on=['year', 'month'], how='left')
data_with_inflation.index = data_df.index
data_with_inflation = data_with_inflation.drop(columns=['year', 'month'])

# Курсы валют и индексы
joined_df = data_with_inflation \
    .merge(currency_df, left_index=True, right_index=True, how='left') \
    .merge(moex_df, left_index=True, right_index=True, how='left')
joined_df[['usd/rub', 'MOEX']] = joined_df[['usd/rub', 'MOEX']].ffill().bfill()

# Изменения
joined_df['usd/rub'] = joined_df['usd/rub'].pct_change() * 100
joined_df['MOEX'] = joined_df['MOEX'].pct_change() * 100
joined_df[['usd/rub', 'MOEX']] = joined_df[['usd/rub', 'MOEX']].fillna(0)
joined_df = joined_df[['balance', 'inflation', 'usd/rub', 'MOEX']]
joined_df.head()
Out[15]:
balance inflation usd/rub MOEX
date
2017-01-09 -0.155904 5.0 0.000000 0.000000
2017-01-10 -0.125572 5.0 0.000000 1.186659
2017-01-11 0.007767 5.0 0.095499 -0.843803
2017-01-12 0.800391 5.0 0.347103 -0.297934
2017-01-13 -0.019721 5.0 -1.103033 -0.759946

Анализ данных¶

Посмотрим на исследуемый ряд

In [16]:
colors = ['#69995D', '#1985A1', '#C95D63']
for col, color in zip(data.columns, colors):
    plot_ts_plotly([data[col]], title=col, colors=[color])

Попробуем разделить баланс на компоненты. Так как он может быть отрицательным, посмотрим только на аддитивные составляющие

In [17]:
plot_decomposed_ts(data['balance'])
No description has been provided for this image

Видно, что остатки остаются достаточно большими даже после разложения ряда, поэтому, скорее всего, нужны будут внешние факторы

Посмотрим на скользящее среднее для balance

In [18]:
fig, axes = plt.subplots(figsize=(20, 14), nrows=3, dpi=150)
colors = ['tab:blue', 'tab:orange', 'tab:green']
labels = ['Исходный ряд', 'Скользящее среднее с окном в неделю', 'Скользящее среднее с окном в месяц']
for ax, window, label, color in zip(axes, [0, 7, 30], labels, colors):
    if window == 0:
        df = data[['balance']]
    else:
        df = data[['balance']].rolling(window).mean()
    df = df.reset_index(names=['date'])
    sns.lineplot(
        df, x='date', y='balance',
        color=color,
        ax=ax,
        alpha=0.7
    )
    make_ax_better(ax, locators=['x', 'y'])
    ax.set_xlim(data.index.min(), data.index.max())
    ax.set_title(label, fontsize=20)
    ax.axhline(0, lw=2, ls='--', color='tab:red')
    ax.set_xlabel('')
plt.tight_layout(h_pad=2)
No description has been provided for this image

Заметно, что после 2020 стало гораздо меньше положительных дней и больше отрицательных

In [19]:
fig, ax = plt.subplots(figsize=(14, 3), dpi=150)
bins_kws = {
    'stat': 'percent', 'ax': ax,
    'binrange': (-1.5, 1.5),
    'bins': 50,
    'alpha': 0.6
}
s1 = data[data.index.year < 2020].balance
s2 = data[data.index.year >= 2020].balance
sns.histplot(s1, **bins_kws, label='До 2020')
sns.histplot(s2, **bins_kws, label='После 2020')
ax.set_title('Распределение дневного сальдо')
make_ax_better(ax)
ax.set_ylabel('')
ax.set_xlabel('')
ax.legend()
plt.show()
display(pd.DataFrame(s1.describe().round(3).rename('До 2020')).T)
display(pd.DataFrame(s2.describe().round(3).rename('После 2020')).T)
No description has been provided for this image
count mean std min 25% 50% 75% max
До 2020 1087.0 -0.012 0.286 -2.38 -0.094 0.0 0.083 1.41
count mean std min 25% 50% 75% max
После 2020 456.0 -0.137 0.288 -2.51 -0.245 -0.01 0.0 0.498

Проверим стационарность по ADF до 2020 и после 2020

In [20]:
# ADF Test
result = adfuller(s1, autolag='AIC')
print('Данные до 2020')
print(f'ADF Statistic: {result[0]}')
print(f'p-value: {result[1]}')
print('\nДанные после 2020')
result = adfuller(s2, autolag='AIC')
print(f'ADF Statistic: {result[0]}')
print(f'p-value: {result[1]}\n')
for key, value in result[4].items():
    print('Critial Values:')
    print(f'   {key}, {value}')
Данные до 2020
ADF Statistic: -8.310292749794408
p-value: 3.8050701720556535e-13

Данные после 2020
ADF Statistic: -2.529280361872583
p-value: 0.10851164759906601

Critial Values:
   1%, -3.445231637930579
Critial Values:
   5%, -2.8681012763264233
Critial Values:
   10%, -2.5702649212751583

Видно, что до 2020 года ряд был стационарным, а после уже перестал

Первичные найденные особенности1. income и outcome имеют тренд и растут с начала промежутка к концу (возможно, следствие инфляции).¶

  1. balance после 2020 года в большей степени находится в отрицательной зоне и даже перестает быть стационарным (хотя ранее был). Визуально наблюдается тренд снижения сальдо с 2020 года и рост дисперсии, а по распределению сальдо видно, как его среднее смещается в отрицательную зону.
  2. Разложение на компоненты оставляет достаточно закономерные ошибки в остатках, поэтому нужно смотреть на внешние факторы.

Исходя из ранее отмеченных предпосылок рассмотрим детальнее данные после 2020 года

Рассмотрение данных после 2020 года¶

In [21]:
clear_data = data[data.index.year >= 2020].copy()
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
clear_data.balance.plot(ax=ax)
make_ax_better(ax, locators=['x', 'y'])
No description has been provided for this image

Сразу отметим, что наблюдается что-то странное в конце промежутка (очень большой отток, причина которого пока не ясна)

In [22]:
clear_data[-8:-3]
Out[22]:
income outcome balance
date
2021-03-24 1.94 2.53 -0.587778
2021-03-25 2.30 3.17 -0.869810
2021-03-26 2.17 4.69 -2.510000
2021-03-27 0.00 0.00 0.000000
2021-03-28 0.00 0.00 0.000000

Вероятно, судя по графикам и дате, в этот день произошла крупная выплата налога.

Ну, или...

No description has been provided for this image

Посмотрим на нули в данных

In [23]:
values_by_sign = clear_data.copy()
values_by_sign['flag'] = values_by_sign['balance'].apply(
    func=lambda x: 0 if x == 0 else (1 if x > 0 else (2 if x < 0 else np.nan))
)
pd.concat([
    values_by_sign['flag'].value_counts(),
    values_by_sign['flag'].value_counts(normalize=True)*100,
], axis=1)
Out[23]:
count proportion
flag
2 256 56.140351
0 111 24.342105
1 89 19.517544

24% данных это нули, при этом в 19.5% значения больше нуля, а в 56% дней меньше нуля. Посмотрим на причину нулей.

In [24]:
values_by_sign[values_by_sign['flag']==0].index.dayofweek.value_counts()
Out[24]:
date
6    57
5    45
2     3
4     3
1     2
3     1
Name: count, dtype: int64

В большинстве своем нули на выходных, однако есть нули и в другие дни, и, кроме того, вероятно, что если у нас выходной, то это не значит, что будет нулевое сальдо

In [25]:
weekdays_data = clear_data.reset_index().copy()
weekdays_data['is_holiday'] = weekdays_data['date'].isin(holidays['holidays'])
weekdays_data['is_nowork'] = weekdays_data['date'].isin(holidays['nowork'])
weekdays_data['dayofweek'] = weekdays_data['date'].dt.dayofweek
weekdays_data.groupby(['dayofweek', 'is_holiday', 'is_nowork'])['balance'].describe()
Out[25]:
count mean std min 25% 50% 75% max
dayofweek is_holiday is_nowork
0 False False 52.0 -0.438192 0.321565 -1.268604 -0.585338 -0.319524 -0.210214 1.110426e-01
True 5.0 -0.337385 0.182710 -0.542662 -0.500265 -0.329129 -0.161410 -1.534603e-01
True False 8.0 -0.042156 0.058171 -0.145435 -0.087801 -0.004867 -0.000007 -4.608431e-09
1 False False 56.0 -0.237236 0.315035 -1.470000 -0.366815 -0.216313 -0.054176 2.258344e-01
True 5.0 -0.134236 0.193194 -0.408989 -0.235376 -0.082896 -0.036284 9.236324e-02
True False 4.0 -0.001345 0.007902 -0.012179 -0.003045 0.000000 0.001700 6.798414e-03
2 False False 54.0 -0.125936 0.253519 -0.873672 -0.246804 -0.120690 0.083994 2.331369e-01
True 7.0 -0.111769 0.389235 -0.953475 -0.117703 0.000000 0.127601 1.512980e-01
True False 5.0 -0.006042 0.012861 -0.029040 -0.000828 -0.000342 0.000000 0.000000e+00
3 False False 57.0 -0.121022 0.260412 -0.869810 -0.310758 -0.091844 0.065129 3.383190e-01
True 6.0 0.055505 0.248341 -0.339241 -0.085235 0.149735 0.224378 2.871016e-01
True False 2.0 -0.000010 0.000014 -0.000020 -0.000015 -0.000010 -0.000005 0.000000e+00
4 False False 55.0 -0.164068 0.421128 -2.510000 -0.313004 -0.119412 0.075294 4.984655e-01
True 5.0 -0.026249 0.074127 -0.128825 -0.064412 -0.015557 0.012027 6.552262e-02
True False 5.0 -0.042225 0.060164 -0.129086 -0.082038 0.000000 0.000000 0.000000e+00
5 False False 1.0 -0.266053 NaN -0.266053 -0.266053 -0.266053 -0.266053 -2.660526e-01
True False 64.0 0.001213 0.018652 -0.027359 0.000000 0.000000 0.000000 1.345602e-01
6 True False 65.0 -0.000238 0.002542 -0.017241 0.000000 0.000000 0.000000 8.004304e-03

Как и предполагалось, даже в выходные бывают перетоки. После обсуждения с заказчиком установили, что перетоки в выходные могут не учитываться моделью и можно по умолчанию считать, что в выходные balance = 0.

Как мы помним, ряд после 2020 года на уровнях значимости 0.05 и 0.1 не стационарен, поэтому перейдем к разностям, чтобы посмотреть на автокорреляции

In [26]:
clear_data_diff = clear_data.diff().dropna()
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
clear_data_diff.balance.plot(ax=ax)
make_ax_better(ax, locators=['x', 'y'])
No description has been provided for this image
In [27]:
result = adfuller(clear_data_diff.balance, autolag='AIC')
print(f'ADF Statistic: {result[0]}')
print(f'p-value: {result[1]}')
for key, value in result[4].items():
    print('Critial Values:')
    print(f'   {key}, {value}')
ADF Statistic: -9.552603335696045
p-value: 2.5481185631094414e-16
Critial Values:
   1%, -3.445231637930579
Critial Values:
   5%, -2.8681012763264233
Critial Values:
   10%, -2.5702649212751583

Исходя из дисперсии, даже несмотря на результаты теста видно, что ее тяжело назвать постоянной, но придется с этим жить

Автокорреляции на данных с 2020 года¶

In [28]:
fig, axes = plt.subplots(figsize=(25, 6), dpi=150, ncols=2)
plot_acf(clear_data_diff.balance.values, ax=axes[0], lags=60)
plot_pacf(clear_data_diff.balance.values, ax=axes[1], lags=60)
for ax in axes:
    make_ax_better(ax)
    ax.set_xticks([i for i in range(0, 61, 2)])
plt.show()
No description has been provided for this image

В целом видно, что есть автокорреляции, причем отрицательные в большей степени

Влияние внешних факторов на баланс¶

RUONIA¶

In [29]:
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
df = data[['balance']].rolling(7).mean().reset_index()
sns.lineplot(
    df, x='date', y='balance',
    color=color,
    ax=ax,
    alpha=0.7,
    label='Сальдо'
)
make_ax_better(ax, locators=['x', 'y'])
ax.set_xlim(data.index.min(), data.index.max())
ax.set_title('Скользящее среднее за неделю сальдо и ставка кредитования', fontsize=20)
ax.axhline(0, lw=2, ls='--', color='tab:red')
ax.set_xlabel('')
h1, l1 = ax.get_legend_handles_labels()
rate_ax = ax.twinx()
sns.lineplot(
    rate_df.reset_index(), x='date', y='rate',
    color='tab:red',
    ax=rate_ax,
    alpha=0.7,
    label='ROUNIA, %'
)
h2, l2 = rate_ax.get_legend_handles_labels()
ax.legend(
    h1+h2, l1+l2
)
rate_ax.set_ylabel('ROUNIA, %')
rate_ax.get_legend().remove()
rate_ax.set_xlabel('')
plt.show()
No description has been provided for this image

В целом видно, что ставка в 2019-2020 году шла по траектории снижения, поэтому, вероятно, могли быть оттоки с банковских депозитов на более доходные инструменты (фондовый рынок) и, например, на потребление, что для банка выражалось в отрицательном сальдо, однако напрямую эти данные вряд ли получится использовать.

Еще интересно, что резкие пики по сальдо приводили к скачам RUONIA впоследствии, посмотрим на это детальнее.

In [30]:
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
df = data[['balance']].rolling(7).mean().reset_index().tail(365)
sns.lineplot(
    df, x='date', y='balance',
    color=color,
    ax=ax,
    alpha=0.7,
    label='Сальдо'
)
make_ax_better(ax, locators=['x', 'y'])
ax.set_title('Скользящее среднее за неделю сальдо и ставка кредитования (данные за 1 год)', fontsize=20)
ax.axhline(0, lw=2, ls='--', color='tab:red')
ax.set_xlabel('')
h1, l1 = ax.get_legend_handles_labels()
rate_ax = ax.twinx()
sns.lineplot(
    rate_df.reset_index().tail(252), x='date', y='rate',
    color='tab:red',
    ax=rate_ax,
    alpha=0.7,
    label='ROUNIA, %'
)
h2, l2 = rate_ax.get_legend_handles_labels()
ax.legend(
    h1+h2, l1+l2
)
rate_ax.set_ylabel('ROUNIA, %')
rate_ax.get_legend().remove()
rate_ax.set_xlabel('')
plt.show()
No description has been provided for this image

Видно, как за резким снижением сальдо следовало с задержкой снижение ставки в некоторых моментах

MOEX¶

In [31]:
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
df = joined_df[['balance', 'MOEX']].rolling(30).mean().reset_index()
# Первая ось — сальдо
sns.lineplot(
    df, x='date', y='balance',
    color='#1985A1',
    ax=ax,
    alpha=0.7,
    label='Сальдо'
)
make_ax_better(ax, locators=['x', 'y'])
ax.set_title('Скользящее среднее за месяц: сальдо и MOEX (данные за 1 год)', fontsize=20)
ax.axhline(0, lw=2, ls='--', color='tab:red')
ax.set_xlabel('')
h1, l1 = ax.get_legend_handles_labels()

# Вторая ось — инфляция
infl_ax = ax.twinx()
sns.lineplot(
    df, x='date', y='MOEX',
    color='tab:orange',
    ax=infl_ax,
    alpha=0.7,
    label='MOEX, %'
)
h2, l2 = infl_ax.get_legend_handles_labels()

# Объединённая легенда
ax.legend(h1 + h2, l1 + l2)
infl_ax.set_ylabel('MOEX, %')
infl_ax.get_legend().remove()
infl_ax.set_xlabel('')

plt.show()
No description has been provided for this image

USD¶

In [32]:
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)
df = joined_df[['balance', 'usd/rub']].rolling(30).mean().reset_index()
# Первая ось — сальдо
sns.lineplot(
    df, x='date', y='balance',
    color='#1985A1',
    ax=ax,
    alpha=0.7,
    label='Сальдо'
)
make_ax_better(ax, locators=['x', 'y'])
ax.set_title('Скользящее среднее за месяц: сальдо и USD/RUB', fontsize=20)
ax.axhline(0, lw=2, ls='--', color='tab:red')
ax.set_xlabel('')
h1, l1 = ax.get_legend_handles_labels()

# Вторая ось — инфляция
infl_ax = ax.twinx()
sns.lineplot(
    df, x='date', y='usd/rub',
    color='tab:orange',
    ax=infl_ax,
    alpha=0.7,
    label='USD/RUB, %'
)
h2, l2 = infl_ax.get_legend_handles_labels()

# Объединённая легенда
ax.legend(h1 + h2, l1 + l2)
infl_ax.set_ylabel('USD/RUB, %')
infl_ax.get_legend().remove()
infl_ax.set_xlabel('')

plt.show()
No description has been provided for this image

Инфляция¶

In [33]:
fig, ax = plt.subplots(figsize=(20, 5), dpi=150)

df = joined_df[['balance', 'inflation']].rolling(30).mean().reset_index()
# Первая ось — сальдо
sns.lineplot(
    df, x='date', y='balance',
    color='tab:red',
    ax=ax,
    alpha=0.7,
    label='Сальдо'
)
make_ax_better(ax, locators=['x', 'y'])
ax.set_title('Скользящее среднее за месяц: сальдо и инфляция', fontsize=20)
ax.axhline(0, lw=2, ls='--', color='tab:red')
ax.set_xlabel('')
h1, l1 = ax.get_legend_handles_labels()

# Вторая ось — инфляция
infl_ax = ax.twinx()
sns.lineplot(
    df, x='date', y='inflation',
    color='tab:green',
    ax=infl_ax,
    alpha=0.7,
    label='USD/RUB, %'
)
h2, l2 = infl_ax.get_legend_handles_labels()

# Объединённая легенда
ax.legend(h1 + h2, l1 + l2)
infl_ax.set_ylabel('Инфляция, %')
infl_ax.get_legend().remove()
infl_ax.set_xlabel('')

plt.show()
No description has been provided for this image

Возможно, оттоки увеличиваются именно из-за изменения инфляции, по крайней мере в них наблюдаются контртренды

Модель для предсказания¶

Описание пайплайна модели¶

Модель основывается на CatBoost.

  1. Подготовка данных
    • Для бейзлайна: временной ряд (balance) и датафрейм с датами налогов в разрезе типов.
    • Для модели с внешними факторами добавляется также индекс мосбиржи, инфляция и курс рубля.
  2. Подготовка признаков (feature engineering)
    • Временные признаки: день недели, месяца, месяц, флаги интервалов дней в месяце с 15 по 19, с 20 по 24, после 25, выходной/предпраздничный день.
    • Лаговые признаки для целевой переменной: значения 1-30 дней назад.
    • Флаги выплаты налога данного типа в выбранный день.
    • Лаговые признаки для внешних факторов (для модели с внешними факторами): изменения индекса мосбиржи, курса доллара и инфляции.
  3. Выбор признаков (feature selection)
    • Были рассмотрены различные подходы, однако в качестве наиболее подходящего (сочетание качества и скорости) используется внутренний (embedded) метод ранжирования признаков по значимости (на основе встроенного feature_importance в базовой модели CatBoost). Порог выбирается так, чтобы отобрать не менее половины всех исходных признаков на каждом фолде.
    • Для отбора признаков используется кросс-валидация с фиксированным размером тренировочной и тестовой выборок. На каждой выборке тренируется и тестируется модель, в результате чего выбирается не менее половины всех признаков. Далее по результатам кросс-валидации отбираются признаки, которые были выбраны не менее, чем в половине всех фолдов.
  4. Подбор гиперпараметров (hyperparameters optimizing)
    • Для отбора наилучшего набора гиперпараметров CatBoost используется библиотека Optuna с кросс-валидацией.
  5. Тренировка финальной модели
    • На основе полученных гиперпараметров происходит тренировка модели на последнем доступном промежутке, после чего модель готова предсказывать баланс на следующий день.

Промежуток обучения и дообучения¶

Для обучения модели рассматривается промежуток в 32 последних недели, а горизонт предсказания - 1 день. При этом так как внутри используется кросс-валидация, то для полноценной тренировки нужен набор данных, содержащий несколько фолдов, поэтому рассматривается промежуток с начала 2020 года (момент, когда логика изменилась и баланс стал быть более часто отрицательным).

Предполагается, что модель будет дообучаться каждую неделю, что обосновывается несколькими факторами:

  1. В выходные нет активных транзакций, поэтому удобно использовать это время для переобучения модели.
  2. Неделя - элемент периодичности нашей модели, поэтому дополнять каждый раз модель новой неделей представляется логичным, чтобы пополнять цепочку накопленных знаний и поддерживать актуальность.

Выбор метрики¶

Бизнес-постановка¶

Введем несколько величин:

  • $r_{key}$ - ключевая ставка
  • $r_{add}$ - ставка при размещении деривативов для получения дополнительной маржи. $r_{add} = r_{key} + 0.5$
  • $r_{surplus}$ - ставка по профицитной ликвидности, размещаемой по overnight-ставке в ЦБ. $r_{surplus} = r_{key} - 0.9$
  • $r_{deficit}$ - ставка по дефицитной ликвидности, которую приходится привлекать за счет займа в ЦБ. $r_{deficit} = r_{key} + 1$

Предположим, что у нас есть некоторое предсказание $\hat{b}_t$ - сальдо на день $t$, а также реальное значение ${b}_t$ сальдо за этот день. Существует 2 основных сценария действий:

  • $\hat{b}_t > 0$, тогда полученные средства могут быть размещены в деривативы для получения маржи под ставку $r_{add}$, таким образом образуется дополнительная доходность $\hat{b}_t \cdot r_{add}$.
  • $\hat{b}_t < 0$, тогда необходимо заимствовать деньги под $r_{deficit}$, таким образом генерируется убыток $\hat{b}_t \cdot r_{deficit}$

При этом в зависимости от того, какое сальдо будет по факту, мы можем попасть в две ситуации:

  • $\hat{b}_t > b_t$, то есть было предсказано больше, чем пришло по факту. Таким образом, нам придется занимать у цб $\hat{b}_t-b_t$ средств под ставку $r_{deficit}$, чтобы покрыть дефицит на день, при этом мы заработаем $\hat{b}_t \cdot r_{add}$ за счет размещения средств в деривативы.
  • $\hat{b}_t < b_t$, то есть было предсказано меньше, чем пришло по факту. Таким образом, потеря будет в том, что мы недозаработали $(b_t - \hat{b}_t) \cdot (r_{add} - r_{surplus})$ денег, так как вместо размещения в деривативы будем вынуждены размещать под overnight.

Использование метрики в оптимизации¶

Выбранная метрика используется в TargetLoss-классе для того, чтобы наиболее оптимальным образом подбирать гиперпараметры для модели. При этом также выбранная метрика дополнительно используется в CatBoost в качестве кастомной, что обеспечивает нацеленность модели на бизнес-результат.

Кроме того, так как есть пожелание со стороны заказчика о том, что ошибка в абсолютном значении не должна превосходить 0.42, то это также учитывается за счет добавления фиксированного штрафа в ошибку в случае превышения этой границы моделью.

Выбор признаков (feature selection)¶

Всего рассмотрены 3 метода отбора признаков:

  1. Встроенный (на основе feature importance в catboost)
  2. Оберточный
  3. Фильтрационный (взаимная информация признака и таргета)

Для выбора метода был проведен анализ стабильности выбора признаков по сплитам кросс-валидации. Рассматриваемые метрики для сравнения:

diversity_index (Индекс разнообразия)Формула:¶

$$ D = \frac{-\sum p_j \ln p_j}{\ln N}, \quad \text{где } p_j = \frac{\text{Частота выбора признака } j}{n} $$

Что показывает:
Равномерность распределения частот выбора признаков.

Интерпретация:

  • D = 1: Все признаки выбираются одинаково часто (максимальное разнообразие).
  • D = 0: Один признак выбирается всегда (минимальное разнообразие).

Примечание:

  • Чем ближе значение к 1, тем выше разнородность выбора признаков.
  • Чем ближе к 0, тем сильнее доминирование отдельных признаков.

pairwise_jaccard (Попарный Жаккар)Формула:¶

$$ J = \frac{1}{C(n,2)} \sum J(A_i, A_j) $$

Что показывает:
Среднее сходство всех пар фолдов.

Интерпретация:

  • $J > 0.7$: Высокая стабильность.
  • $J < 0.3$: Низкая стабильность.

feature_consistency (Консистентность)Формула:¶

$$ C = \frac{1}{N} \sum \frac{\text{Выборов признака}}{\text{Фолдов}} $$

Что показывает:
Среднюю частоту выбора каждого признака.

Интерпретация:

  • $С = 1 $: Признак выбирается всегда..
  • $С = 0 $: Признак не выбирается никогда.
In [144]:
# Импортируем нужные библиотеки и реализовальный модуль Feature_selection

from src.feature_selection import selectors
from sklearn.metrics import jaccard_score
import time
from itertools import combinations
from scipy.stats import entropy

def diversity_index(masks):
    """Индекс разнообразия на основе энтропии
    
    Вычисляет нормализованную энтропию частот выбора признаков.
    """
    selection_freq = np.mean(masks, axis=0)
    return entropy(selection_freq)/np.log(len(selection_freq))

def pairwise_jaccard(masks):
    """Средний попарный коэффициент Жаккара
    
    Вычисляет среднее сходство выбора признаков 
    между всеми парами масок (бинарных векторов)
    """
    pairs = list(combinations(masks, 2))
    return np.mean([jaccard_score(a, b) for a, b in pairs])

def feature_consistency(masks):
    """Средняя консистентность признаков
    
    Вычисляет среднюю частоту выбора признаков 
    по всем фолдам:
    """
    return np.mean(np.sum(masks, axis=0) / len(masks))


# Функция для расчета всех метрик
def calculate_all_metrics(masks):
    masks_array = np.array(masks)
    return {
        'pairwise_jaccard': pairwise_jaccard(masks),
        'feature_consistency': feature_consistency(masks),
        'diversity_index': diversity_index(masks_array),
    }
In [145]:
# Получим из нашей модели данные и фичи
model = BaselineModel(
    hyperparams_optimizer_kws = {'optuna_n_trials': 1}
)
X, y = model.prepare_data(df=data, taxes=taxes, holidays=holidays)

# Используем функцию из нашей модели для кросс валидации
from src.model_evaluation.cross_validation import (
    split_period_for_cross_val
)
df = data.copy()

df['date'] = pd.to_datetime(df['date'])

df = df.set_index('date')
min_date = df.index.min()
max_date = df.index.max()

splits = split_period_for_cross_val(
    min_date=min_date,
    max_date=max_date,  
    test_size_weeks=1,
    train_size_weeks=32,
    n_folds=5,
    seed=42
)

# Преобразование существующих сплитов
sorted_splits = sorted(
    [(np.array(train), np.array(test)) for train, test in splits],
    key=lambda x: x[1][0] if len(x[1]) > 0 else 0
)
In [148]:
embedded_selector = selectors.SelectFromModelEmbeddedFeatureSelector()
wrapper_selector = selectors.WrapperFeatureSelector()
filter_selector = selectors.FilterFeatureSelector()

stability_results = {
    "embedded": {"masks": [], "time": []},
    "wrapper": {"masks": [], "time": []},
    "filter": {"masks": [], "time": []}
}


for fold_number, (train_index, test_index) in enumerate(sorted_splits):
    X_train, X_test = X.iloc[train_index], X.iloc[test_index]
    y_train, y_test = y.iloc[train_index], y.iloc[test_index]
    
    print(f"\nFold {fold_number + 1}:")
    
    for method in ['embedded', 'wrapper', 'filter']:
        start_time = time.perf_counter()
        
        if method == 'embedded':
            feats = embedded_selector.select_features(X_train, y_train)
        elif method == 'wrapper':
            feats = wrapper_selector.select_features(X_train, y_train)
        else:
            feats = filter_selector.select_features(X_train, y_train)
            
        exec_time = time.perf_counter() - start_time
        mask = X.columns.isin(feats).astype(int)
        
        # Сохраняем результаты
        stability_results[method]['masks'].append(mask)
        stability_results[method]['time'].append(exec_time)
        
        print(f"{method.upper()} selected features in {exec_time:.4f}s")
Fold 1:
EMBEDDED selected features in 1.3603s
WRAPPER selected features in 56.6791s
FILTER selected features in 0.0930s

Fold 2:
EMBEDDED selected features in 1.2940s
WRAPPER selected features in 54.1022s
FILTER selected features in 0.0897s

Fold 3:
EMBEDDED selected features in 1.3143s
WRAPPER selected features in 53.7507s
FILTER selected features in 0.0888s

Fold 4:
EMBEDDED selected features in 1.3732s
WRAPPER selected features in 54.3163s
FILTER selected features in 0.0916s

Fold 5:
EMBEDDED selected features in 1.3705s
WRAPPER selected features in 53.5609s
FILTER selected features in 0.0914s
In [159]:
# Собираем результаты
final_results = {}
for method in ['embedded', 'wrapper', 'filter']:
    masks = np.array(stability_results[method]['masks'])
    final_results[method] = {
        'metrics': calculate_all_metrics(masks),
        'avg_time': np.mean(stability_results[method]['time'])
    }

# Собираем данные в плоский формат
tmp = []
for method in ['embedded', 'wrapper', 'filter']:
    row = {
        'Method': method,
        'Average Time': final_results[method]['avg_time']
    }
    row.update(final_results[method]['metrics'])
    tmp.append(row)

# Создаем DataFrame
df = pd.DataFrame(tmp).set_index('Method').T
df.index.name = 'Metric'
df = df.reset_index()

df.columns = ['Metric', 'EMBEDDED', 'WRAPPER', 'FILTER']

is_metric_row = df['Metric'] != 'Average Time'  # Фильтр по названию метрики
is_time_row = df['Metric'] == 'Average Time' # Фильтр для времени

# Форматируем вывод
styled_df = df.style \
    .hide(axis='index') \
    .format(
        formatter="{:.4f}",
        subset=pd.IndexSlice[:, ['EMBEDDED', 'WRAPPER', 'FILTER']]
    ) \
    .set_caption('Сравнение методов отбора признаков') \
    .set_properties(**{
        'text-align': 'center',
        'border': '1px solid #dee2e6',
        'padding': '5px'
    }) \
    .set_table_styles([{
        'selector': 'th',
        'props': [('background-color', '#f8f9fa')]
    }]) \
    .applymap(
        lambda x: 'color: green' if (isinstance(x, float) and x >= 0.7) else '',
        subset=pd.IndexSlice[is_metric_row, ['EMBEDDED', 'WRAPPER', 'FILTER']]
    ) \
    .applymap(
        lambda x: 'color: red' if (isinstance(x, float) and x <= 0.3) else '',
        subset=pd.IndexSlice[is_metric_row, ['EMBEDDED', 'WRAPPER', 'FILTER']]
        # Градиент для времени
    ) \
    .background_gradient(
        subset=pd.IndexSlice[is_time_row, ['EMBEDDED', 'WRAPPER', 'FILTER']],
        cmap='YlOrRd',  # Красно-желто-зеленый градиент
        vmin=0,         # Минимальное время (подстройте под ваши данные)
        vmax=50         
    )

styled_df
C:\Users\ilya\AppData\Local\Temp\ipykernel_17276\3450108959.py:31: FutureWarning:

Styler.applymap has been deprecated. Use Styler.map instead.

Out[159]:
Сравнение методов отбора признаков
Metric EMBEDDED WRAPPER FILTER
Average Time 1.3425 54.4818 0.0909
pairwise_jaccard 0.8765 0.8701 0.5357
feature_consistency 0.5407 0.4938 0.4938
diversity_index 0.8768 0.8573 0.9248

Вывод:

  1. FILTER-метод — самый быстрый (0.09 сек) и разнообразный (diversity=0.92), но наименее стабильный (Jaccard=0.53).
  2. EMBEDDED — оптимален для баланса скорости (1.34 сек) и стабильности (Jaccard=0.87).
  3. WRAPPER — крайне медленный (54.48 сек), но стабильный — подходит для точного отбора при наличии ресурсов.

Модель без внешних признаков¶

In [35]:
train_data = clear_data[['balance']].reset_index()
train_df = train_data[:-28].copy()
test_df = train_data[-28:].copy()
model = BaselineModel(
    cross_val_split_kws = {
        'train_size_weeks': 32, 
        'test_size_weeks': 1,
        'n_folds': 5
    },
    hyperparams_optimizer_kws = {
        'optuna_n_trials': 50,
        'cross_val_score_kws': {
            'loss': TargetLoss(rate_df['rate']),
            'additional_metrics': {
                'max_ae': MaxAE(),
                'mae': MAE(),
                'num_errors_over_limit': NumCriticalErrors(),
                'money_loss': MoneyLoss(rate_df['rate'])
            }
        }
    },
    feature_selector_kws={'threshold': '0.75*median'}
)
model.fit(df=train_df, taxes=taxes, holidays=holidays)
[I 2025-04-27 21:56:25,537] A new study created in memory with name: no-name-5e944279-b877-4c95-9985-b5032b14f356
[I 2025-04-27 21:56:34,414] Trial 0 finished with value: 0.8001118486549157 and parameters: {'iterations': 1724, 'learning_rate': 0.0025638433420732123, 'depth': 5, 'l2_leaf_reg': 0.5560508061057889, 'random_strength': 3.0532064296169783, 'bagging_temperature': 0.8074583720997661, 'max_ctr_complexity': 1}. Best is trial 0 with value: 0.8001118486549157.
[I 2025-04-27 21:56:43,304] Trial 1 finished with value: 0.6000987038118161 and parameters: {'iterations': 1270, 'learning_rate': 0.013044907802805577, 'depth': 5, 'l2_leaf_reg': 0.027369441764082774, 'random_strength': 0.8824350467993378, 'bagging_temperature': 0.31349373473780573, 'max_ctr_complexity': 3}. Best is trial 1 with value: 0.6000987038118161.
[I 2025-04-27 21:56:48,127] Trial 2 finished with value: 0.6001040369682398 and parameters: {'iterations': 473, 'learning_rate': 0.13761958257518794, 'depth': 7, 'l2_leaf_reg': 1.0925094027435385, 'random_strength': 9.982330578501154, 'bagging_temperature': 1.7880524331870002, 'max_ctr_complexity': 1}. Best is trial 1 with value: 0.6000987038118161.
[I 2025-04-27 21:56:58,565] Trial 3 finished with value: 0.6000926651194716 and parameters: {'iterations': 1190, 'learning_rate': 0.036312113722904445, 'depth': 6, 'l2_leaf_reg': 0.2536496285433246, 'random_strength': 4.648802973057262, 'bagging_temperature': 0.8653310000045378, 'max_ctr_complexity': 4}. Best is trial 3 with value: 0.6000926651194716.
[I 2025-04-27 21:56:59,888] Trial 4 finished with value: 0.8001071055440236 and parameters: {'iterations': 355, 'learning_rate': 0.04023930291824554, 'depth': 3, 'l2_leaf_reg': 5.195551178070165, 'random_strength': 4.0468620251214515, 'bagging_temperature': 0.46050741529178163, 'max_ctr_complexity': 1}. Best is trial 3 with value: 0.6000926651194716.
[I 2025-04-27 21:57:02,958] Trial 5 finished with value: 0.6000913603761562 and parameters: {'iterations': 1036, 'learning_rate': 0.02151458463502839, 'depth': 2, 'l2_leaf_reg': 0.012881383929544153, 'random_strength': 8.565193957430631, 'bagging_temperature': 0.7958453093283946, 'max_ctr_complexity': 3}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:57:26,533] Trial 6 finished with value: 0.6001163859857133 and parameters: {'iterations': 1955, 'learning_rate': 0.001654450703604335, 'depth': 7, 'l2_leaf_reg': 0.5935769619334545, 'random_strength': 2.510864754562687, 'bagging_temperature': 1.4253684301564378, 'max_ctr_complexity': 4}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:57:28,420] Trial 7 finished with value: 0.8001088870528112 and parameters: {'iterations': 492, 'learning_rate': 0.010054832785391392, 'depth': 3, 'l2_leaf_reg': 0.005180996813471535, 'random_strength': 2.4044915766417176, 'bagging_temperature': 0.4333589751462297, 'max_ctr_complexity': 2}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:57:38,244] Trial 8 finished with value: 0.600094972742379 and parameters: {'iterations': 785, 'learning_rate': 0.2608422593801981, 'depth': 7, 'l2_leaf_reg': 0.25949298130909143, 'random_strength': 4.474022282855026, 'bagging_temperature': 0.6842423182069111, 'max_ctr_complexity': 4}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:57:50,495] Trial 9 finished with value: 0.6001083211017784 and parameters: {'iterations': 1728, 'learning_rate': 0.0463611678946233, 'depth': 5, 'l2_leaf_reg': 0.018489197073098287, 'random_strength': 0.29236504299259153, 'bagging_temperature': 1.0353926286279538, 'max_ctr_complexity': 2}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:57:52,799] Trial 10 finished with value: 0.600120683584568 and parameters: {'iterations': 904, 'learning_rate': 0.004998707221879419, 'depth': 2, 'l2_leaf_reg': 0.00010669776009727384, 'random_strength': 7.728790556988808, 'bagging_temperature': 0.01849031839681259, 'max_ctr_complexity': 3}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:58:03,810] Trial 11 finished with value: 0.8001035540316709 and parameters: {'iterations': 1220, 'learning_rate': 0.03723044081714964, 'depth': 6, 'l2_leaf_reg': 0.0015068759114238162, 'random_strength': 6.733500534556204, 'bagging_temperature': 1.1408250434008833, 'max_ctr_complexity': 4}. Best is trial 5 with value: 0.6000913603761562.
[I 2025-04-27 21:58:07,298] Trial 12 finished with value: 0.4000807564177301 and parameters: {'iterations': 1387, 'learning_rate': 0.0753198505973765, 'depth': 2, 'l2_leaf_reg': 0.12678096001997655, 'random_strength': 6.691049075961274, 'bagging_temperature': 1.3100242532173638, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:11,091] Trial 13 finished with value: 0.800087670098943 and parameters: {'iterations': 1500, 'learning_rate': 0.12357279728414654, 'depth': 2, 'l2_leaf_reg': 0.0011605137366224132, 'random_strength': 7.121196729254168, 'bagging_temperature': 1.381915016266066, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:14,188] Trial 14 finished with value: 0.6000953239212864 and parameters: {'iterations': 776, 'learning_rate': 0.06963346127688054, 'depth': 3, 'l2_leaf_reg': 0.06275477080274464, 'random_strength': 9.364423506775218, 'bagging_temperature': 1.9647973983088467, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:17,972] Trial 15 finished with value: 0.800091715852712 and parameters: {'iterations': 1451, 'learning_rate': 0.018912475558088886, 'depth': 2, 'l2_leaf_reg': 0.061358153435259764, 'random_strength': 8.204319255258163, 'bagging_temperature': 1.3535466047234408, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:23,537] Trial 16 finished with value: 0.6001126365678349 and parameters: {'iterations': 1005, 'learning_rate': 0.0062843288037088794, 'depth': 4, 'l2_leaf_reg': 0.007690469612092923, 'random_strength': 5.975549732021241, 'bagging_temperature': 1.7050279403476305, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:31,540] Trial 17 finished with value: 0.6000962941271242 and parameters: {'iterations': 1426, 'learning_rate': 0.019900620792820657, 'depth': 4, 'l2_leaf_reg': 5.553948381510655, 'random_strength': 8.885026252376164, 'bagging_temperature': 1.1605361470268294, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:34,305] Trial 18 finished with value: 0.8000971360277175 and parameters: {'iterations': 649, 'learning_rate': 0.09426821689406721, 'depth': 3, 'l2_leaf_reg': 0.0004724468397456568, 'random_strength': 5.899055193119633, 'bagging_temperature': 0.6635740638225025, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:34,878] Trial 19 finished with value: 0.6000835583331281 and parameters: {'iterations': 196, 'learning_rate': 0.24271396722655453, 'depth': 2, 'l2_leaf_reg': 0.11367105643520259, 'random_strength': 8.425305561472008, 'bagging_temperature': 1.5803434793436941, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:36,178] Trial 20 finished with value: 0.6000955641936111 and parameters: {'iterations': 235, 'learning_rate': 0.24629151329731785, 'depth': 4, 'l2_leaf_reg': 0.13269596722479435, 'random_strength': 5.853975765029121, 'bagging_temperature': 1.6088970392835915, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:36,594] Trial 21 finished with value: 0.800095117756667 and parameters: {'iterations': 127, 'learning_rate': 0.17716642647652764, 'depth': 2, 'l2_leaf_reg': 0.01040124572948982, 'random_strength': 8.144682269241772, 'bagging_temperature': 1.4843052517286903, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:40,196] Trial 22 finished with value: 0.6000857255589549 and parameters: {'iterations': 1669, 'learning_rate': 0.07597106179792484, 'depth': 2, 'l2_leaf_reg': 0.06767656459147055, 'random_strength': 7.102875095611108, 'bagging_temperature': 1.2542293621474248, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:45,407] Trial 23 finished with value: 0.6000828174452322 and parameters: {'iterations': 1672, 'learning_rate': 0.08872294838090046, 'depth': 3, 'l2_leaf_reg': 1.545478780340116, 'random_strength': 6.9868152978927345, 'bagging_temperature': 1.2558280854379535, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:51,770] Trial 24 finished with value: 0.8000812535425054 and parameters: {'iterations': 1921, 'learning_rate': 0.1862387912127672, 'depth': 3, 'l2_leaf_reg': 2.889224501130532, 'random_strength': 6.581745652614557, 'bagging_temperature': 1.6126565227314629, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:55,734] Trial 25 finished with value: 0.6000869702208965 and parameters: {'iterations': 1584, 'learning_rate': 0.06366732185321498, 'depth': 3, 'l2_leaf_reg': 1.5752649214659586, 'random_strength': 7.402484675935327, 'bagging_temperature': 0.9766526837510756, 'max_ctr_complexity': 1}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:58:58,775] Trial 26 finished with value: 0.40009019545474195 and parameters: {'iterations': 1325, 'learning_rate': 0.29855598774751346, 'depth': 2, 'l2_leaf_reg': 0.2208889657298932, 'random_strength': 5.325065672502166, 'bagging_temperature': 1.9127242100553865, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:03,094] Trial 27 finished with value: 0.6000928853628733 and parameters: {'iterations': 1334, 'learning_rate': 0.11613163730150419, 'depth': 3, 'l2_leaf_reg': 9.45123057841076, 'random_strength': 5.533799953659956, 'bagging_temperature': 1.9274030841301903, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:09,318] Trial 28 finished with value: 0.8000983637654009 and parameters: {'iterations': 1844, 'learning_rate': 0.1641911010984219, 'depth': 4, 'l2_leaf_reg': 0.35112494799611726, 'random_strength': 5.174414906779508, 'bagging_temperature': 1.8044617230564555, 'max_ctr_complexity': 1}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:12,394] Trial 29 finished with value: 0.8000839788922274 and parameters: {'iterations': 1762, 'learning_rate': 0.05685570761610727, 'depth': 2, 'l2_leaf_reg': 1.0585985105979054, 'random_strength': 3.6409464603145407, 'bagging_temperature': 1.2157697853167853, 'max_ctr_complexity': 1}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:16,041] Trial 30 finished with value: 0.8000950311155 and parameters: {'iterations': 1142, 'learning_rate': 0.028822511988424074, 'depth': 3, 'l2_leaf_reg': 0.45607309728092565, 'random_strength': 6.354246355720552, 'bagging_temperature': 1.0576620339786782, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:19,554] Trial 31 finished with value: 0.6001015512194143 and parameters: {'iterations': 1584, 'learning_rate': 0.293949338233831, 'depth': 2, 'l2_leaf_reg': 0.12958776206081657, 'random_strength': 7.672758571583746, 'bagging_temperature': 1.5675215316871607, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:22,459] Trial 32 finished with value: 0.6000885990518835 and parameters: {'iterations': 1343, 'learning_rate': 0.09313772892131732, 'depth': 2, 'l2_leaf_reg': 0.17186107402034376, 'random_strength': 5.387609363853004, 'bagging_temperature': 1.8163527391144225, 'max_ctr_complexity': 2}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:25,739] Trial 33 finished with value: 0.6000826630005168 and parameters: {'iterations': 1555, 'learning_rate': 0.20494139135960657, 'depth': 2, 'l2_leaf_reg': 0.6202375422269499, 'random_strength': 9.910903342142007, 'bagging_temperature': 1.5127188905687525, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:28,972] Trial 34 finished with value: 0.6000816658330049 and parameters: {'iterations': 1546, 'learning_rate': 0.15009058836537717, 'depth': 2, 'l2_leaf_reg': 0.6903571305082945, 'random_strength': 9.960689784549938, 'bagging_temperature': 1.2911748812244292, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:31,865] Trial 35 finished with value: 0.6000845815284792 and parameters: {'iterations': 1321, 'learning_rate': 0.18341518325923417, 'depth': 2, 'l2_leaf_reg': 0.6829794559764659, 'random_strength': 9.29582067467878, 'bagging_temperature': 1.7022424771323652, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:34,260] Trial 36 finished with value: 0.40008800608808476 and parameters: {'iterations': 1123, 'learning_rate': 0.13506982710198825, 'depth': 2, 'l2_leaf_reg': 0.02842363480028371, 'random_strength': 9.780836425326614, 'bagging_temperature': 1.4823266830464739, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:42,852] Trial 37 finished with value: 0.800093596217913 and parameters: {'iterations': 1121, 'learning_rate': 0.12079911520304917, 'depth': 6, 'l2_leaf_reg': 0.03125584747297108, 'random_strength': 9.932834113343334, 'bagging_temperature': 1.3348092389579904, 'max_ctr_complexity': 4}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:46,088] Trial 38 finished with value: 0.4000951138049003 and parameters: {'iterations': 935, 'learning_rate': 0.14718576685269227, 'depth': 3, 'l2_leaf_reg': 0.04373898840738072, 'random_strength': 3.111904056656519, 'bagging_temperature': 0.9146434622930857, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:49,268] Trial 39 finished with value: 0.80010652850067 and parameters: {'iterations': 960, 'learning_rate': 0.28649228773162455, 'depth': 3, 'l2_leaf_reg': 0.04512887168926569, 'random_strength': 1.8205374728573127, 'bagging_temperature': 0.8974753002660487, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:52,135] Trial 40 finished with value: 0.6001267865224457 and parameters: {'iterations': 889, 'learning_rate': 0.001315055330220757, 'depth': 3, 'l2_leaf_reg': 0.0038566286503343636, 'random_strength': 3.1448099966807757, 'bagging_temperature': 0.6518611880846126, 'max_ctr_complexity': 4}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:54,515] Trial 41 finished with value: 0.6000939979954463 and parameters: {'iterations': 1105, 'learning_rate': 0.14102324524590748, 'depth': 2, 'l2_leaf_reg': 0.02038563270022037, 'random_strength': 4.018968124599047, 'bagging_temperature': 0.8109435440938544, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 21:59:57,191] Trial 42 finished with value: 0.8000832015925037 and parameters: {'iterations': 1247, 'learning_rate': 0.135632627127907, 'depth': 2, 'l2_leaf_reg': 0.03718955476167692, 'random_strength': 1.295059792898948, 'bagging_temperature': 1.0929279684145856, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:00,140] Trial 43 finished with value: 0.8000911964808441 and parameters: {'iterations': 1400, 'learning_rate': 0.10116792060936959, 'depth': 2, 'l2_leaf_reg': 0.21759854063826278, 'random_strength': 4.495270131345999, 'bagging_temperature': 0.9372037668376946, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:02,741] Trial 44 finished with value: 0.4000840750968095 and parameters: {'iterations': 1209, 'learning_rate': 0.05207603338369929, 'depth': 2, 'l2_leaf_reg': 0.08496706804802007, 'random_strength': 4.759580019072702, 'bagging_temperature': 1.418169384927895, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:05,385] Trial 45 finished with value: 0.8000883170501828 and parameters: {'iterations': 813, 'learning_rate': 0.05514331104631963, 'depth': 3, 'l2_leaf_reg': 0.09384002127457013, 'random_strength': 2.5237098189867107, 'bagging_temperature': 1.429178089044933, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:07,927] Trial 46 finished with value: 0.6000868383857919 and parameters: {'iterations': 1200, 'learning_rate': 0.026551837763383775, 'depth': 2, 'l2_leaf_reg': 0.01663019419480216, 'random_strength': 4.8745395563115865, 'bagging_temperature': 1.8954453436658505, 'max_ctr_complexity': 4}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:11,611] Trial 47 finished with value: 0.6000979256899956 and parameters: {'iterations': 634, 'learning_rate': 0.04401833046577183, 'depth': 5, 'l2_leaf_reg': 0.029351081381876226, 'random_strength': 3.5905136899339194, 'bagging_temperature': 1.7087002034837566, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:14,995] Trial 48 finished with value: 0.6000863755889829 and parameters: {'iterations': 1050, 'learning_rate': 0.0757263372986644, 'depth': 3, 'l2_leaf_reg': 0.0035699696714942566, 'random_strength': 2.8310033991751915, 'bagging_temperature': 0.7718913469181943, 'max_ctr_complexity': 3}. Best is trial 12 with value: 0.4000807564177301.
[I 2025-04-27 22:00:17,902] Trial 49 finished with value: 0.6000867686341339 and parameters: {'iterations': 1259, 'learning_rate': 0.03079432558896357, 'depth': 2, 'l2_leaf_reg': 0.07096417054858983, 'random_strength': 4.303510429774569, 'bagging_temperature': 0.2307708218923259, 'max_ctr_complexity': 4}. Best is trial 12 with value: 0.4000807564177301.
Out[35]:
<src.models.BaselineModel at 0x14da8bbf0>

Метрики модели

In [36]:
model.mean_metrics
Out[36]:
{'max_ae': 0.39059315999444844,
 'mae': 0.11484357166232506,
 'num_errors_over_limit': 0.8,
 'money_loss': 8.973874697678991e-05,
 'target_loss': 0.8000897387469769}
In [37]:
forecast = model.forecast(df=train_data, taxes=taxes, holidays=holidays)
eval_df = pd.concat([
    forecast.rename('y_pred'),
    train_data.set_index('date')['balance'].rename('y_true')
], axis=1).dropna() # оставляем только валидный промежуток
colors = ['#F75C03', '#00CC66']
print('Прерывистой линией отмечено начало тестового промежутка, который не участвовал в обучении')
plot_ts_plotly(
    [eval_df['y_pred'], eval_df['y_true']],
    title='Сравнение предсказания и реальности', 
    colors=colors, v_lines=[{'x': test_df.date.min(), 'label': 'Начало тестового промежутка'}]
)
colors = ['#F75C03', '#00CC66']
print('Прерывистой линией отмечено начало тестового промежутка, который не участвовал в обучении')
plot_ts_plotly(
    [(eval_df['y_pred']-eval_df['y_true']).rename('diff')],
    title='Разность предсказания и реальности', 
    colors=colors, v_lines=[{'x': test_df.date.min(), 'label': 'Начало тестового промежутка'}]
)
Прерывистой линией отмечено начало тестового промежутка, который не участвовал в обучении
Прерывистой линией отмечено начало тестового промежутка, который не участвовал в обучении

Выбранные признаки

In [40]:
model.selected_features
Out[40]:
['is_holiday',
 'balance__lag_1',
 'balance__lag_2',
 'balance__lag_3',
 'balance__lag_4',
 'balance__lag_5',
 'balance__lag_6',
 'balance__lag_7',
 'balance__lag_8',
 'balance__lag_9',
 'balance__lag_10',
 'balance__lag_11',
 'balance__lag_12',
 'balance__lag_13',
 'balance__lag_14',
 'balance__lag_15',
 'balance__lag_16',
 'balance__lag_17',
 'balance__lag_18',
 'balance__lag_19',
 'balance__lag_20',
 'balance__lag_21',
 'balance__lag_22',
 'balance__lag_23',
 'balance__lag_24',
 'balance__lag_25',
 'balance__lag_26',
 'balance__lag_27',
 'balance__lag_28',
 'balance__lag_29',
 'balance__lag_30',
 'day_of_week',
 'day_of_month',
 'month',
 'tax_type_Налог на добавленную стоимость (НДС)',
 'tax_type_Сельскохозяйственным товаропроизводителям',
 'tax_type_Страховые взносы',
 'tax_type_Упрощенная система налогообложения (УСН)',
 'tax_type_Участникам ЕГАИС и другим плательщикам акцизов',
 'is_after_25_day',
 'tax_type_Налог на доходы физических лиц (НДФЛ)',
 'tax_type_Пользователям недр',
 'tax_type_Торговый сбор',
 'is_nowork',
 'tax_type_Система налогообложения в виде единого налога на вмененный доход для отдельных видов деятельности (единый налог) (ЕНВД)',
 'is_after_15_day',
 'tax_type_Иностранным организациям',
 'tax_type_Косвенные налоги']

Определение разладок¶

Для определения разладок рассматривается 2 подхода: на основе значений ряда и на основе ошибок модели.

In [ ]:
from scipy.stats import norm
from src.breakpoints_finder import BreakpointFinder

taxes = pd.read_csv(taxes_filepath)
taxes['date'] = pd.to_datetime(taxes['date'])
with open(holidays_filepath, 'rb') as f:
        holidays = pickle.load(f)
rate_df = pd.read_excel(rate_filepath, engine="openpyxl")
rate_df['date'] = pd.to_datetime(rate_df['DT'])
rate_df = rate_df[['date', 'ruo']].rename(columns={'ruo': 'rate'})
rate_df = rate_df.sort_values('date').set_index('date')
data = pd.read_csv(ts_filepath)
data.columns = [col.lower() for col in data]
data['date'] = pd.to_datetime(data['date'])
for col in data.columns[1:]:
    data[col] = data[col].astype(str).str.replace(',', '.').astype(float)
data = data.set_index('date')
orig_data = data.copy()
clear_data = data[data.index.year >= 2020].copy()

finder = BreakpointFinder(mean_diff=-0.01)

a = clear_data['balance'].values

for k, x_k in enumerate(a):
    finder.update(x_k)
    finder.count_metric()

fig, ax = plt.subplots(figsize=(15,8))

for i in range(1, len(a)):
    
    x = [i-1, i]
    y = [a[i-1], a[i]]
    
    ax.plot(x, y, color=finder.breakpoints[i])
  

plt.title('Красные значения - разладки в ряде')
plt.show()
No description has been provided for this image
In [9]:
model = BaselineModel(
    cross_val_split_kws = {'test_size_weeks': 1, 'train_size_weeks': 15},
    hyperparams_optimizer_kws = {'optuna_n_trials': 5, 'cross_val_score_kws': {
                'loss': TargetLoss (rate_df['rate']),
                'additional_metrics': {'max_ae': MaxAE(), 'mae': MAE(), 'num_errors_over_limit': NumCriticalErrors(), 'money_loss': MoneyLoss (rate_df['rate'])}
            }
        },
    feature_selector_kws={'threshold': '0.5*median'}
)

data = clear_data[['balance']].reset_index()
train_df = data[:-28].copy()
test_df = data[-28:].copy()
model.fit(df=train_df, taxes=taxes, holidays=holidays)
[I 2025-04-27 10:29:39,021] A new study created in memory with name: no-name-de61b1b6-9111-4447-bbc0-a18d32c52b21
[I 2025-04-27 10:29:48,646] Trial 0 finished with value: 17.004566319367985 and parameters: {'iterations': 1097, 'learning_rate': 0.003880943960922875, 'depth': 3, 'l2_leaf_reg': 0.429392481620831, 'random_strength': 6.661066451298646, 'bagging_temperature': 1.0260656372178987, 'max_ctr_complexity': 1}. Best is trial 0 with value: 17.004566319367985.
[I 2025-04-27 10:30:12,363] Trial 1 finished with value: 18.01038813031333 and parameters: {'iterations': 1277, 'learning_rate': 0.15803040173141628, 'depth': 6, 'l2_leaf_reg': 8.838610045672485, 'random_strength': 6.02016375349749, 'bagging_temperature': 1.1346597044835305, 'max_ctr_complexity': 3}. Best is trial 0 with value: 17.004566319367985.
[I 2025-04-27 10:30:23,112] Trial 2 finished with value: 35.30060069801129 and parameters: {'iterations': 1543, 'learning_rate': 0.0029991495914625057, 'depth': 2, 'l2_leaf_reg': 0.0001246720291611491, 'random_strength': 2.3437985102292194, 'bagging_temperature': 1.4015589882292814, 'max_ctr_complexity': 2}. Best is trial 0 with value: 17.004566319367985.
Out[9]:
<src.models.BaselineModel at 0x7b26a3947170>
In [ ]:
forecast = model.forecast(df=data, taxes=taxes, holidays=holidays)
eval_df = pd.concat([
    forecast.rename('y_pred'),
    data.set_index('date')['balance'].rename('y_true')
], axis=1).dropna() # оставляем только валидный промежуток
finder = BreakpointFinder()
a = (eval_df['y_pred']-eval_df['y_true']).values

for k, x_k in enumerate(a):
    finder.update(x_k)
    finder.count_metric()

fig, ax = plt.subplots(figsize=(15,8))

for i in range(1, len(a)):
    
    x = [i-1, i]
    y = [a[i-1], a[i]]
    
    ax.plot(x, y, color=finder.breakpoints[i])
  

plt.title('Красные значения - разладки в ошибках')
plt.show()
No description has been provided for this image